feat(usage): token usage statistics — global page, per-task context capacity, live cache hit rate#95
Conversation
…apacity, live cache hit rate
Adds usage statistics across TUI/Web/ACP:
- Token tracking refactor (internal/model): TokenUsage gains reasoning /
cache-write / call-count; Add() takes AddParams; capture tolerates providers
that omit total_tokens (e.g. GLM) and records once per streamed call. A
context usage notifier pushes per-call updates for live UI.
- Cache hit rate = cached / prompt (provider-portable; shows "—" when the
provider never reports caching).
- Append-only event log (~/.jcode/usage/events.jsonl) + read-time Aggregate for
totals / streak / active-days / heatmap / by-model / by-project. Subagent and
teammate tokens roll up under the leader session.
- Global stats page (Settings → Usage) + GET /api/usage/stats.
- Per-task context-capacity popup (Messages / System tools / MCP tools / Skills /
System prompt + free space + cache hit rate) + GET /api/tasks/{id}/stats;
a context-fill ring on the composer that updates live during a run and is
seeded on resume.
- i18n (en / zh-Hans / zh-Hant / ja / ko), docs/usage-stats.md, unit + httptest
coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds a complete usage statistics system: a richer ChangesUsage Statistics System
Sequence DiagramssequenceDiagram
rect rgba(100, 149, 237, 0.5)
Note over runner.Run,usage.Store: Per-turn recording (backend)
runner.Run->>runner.Run: snapshot startUsage
runner.Run->>model.WithUsageNotifier: install notifier fn in ctx
model.recordUsage->>handler.OnTokenUpdate: mid-run token update
runner.Run->>runner.recordUsageTurn: tracker + startUsage + recorder
runner.recordUsageTurn->>usage.Store: RecordEvent(delta Event)
end
rect rgba(144, 238, 144, 0.5)
Note over Browser,internal/web/usage.go: Stats API fetch (frontend → backend)
Browser->>internal/web/usage.go: GET /api/usage/stats?days=30
internal/web/usage.go->>usage.Store: Load(since)
usage.Store-->>internal/web/usage.go: []Event
internal/web/usage.go->>usage.Aggregate: Aggregate(events, today)
usage.Aggregate-->>Browser: JSON{totals, heatmap, by_model…}
Browser->>internal/web/usage.go: GET /api/tasks/{id}/stats
internal/web/usage.go->>breakdownFn: ContextBreakdown (if active)
internal/web/usage.go-->>Browser: JSON{is_active, context, cache…}
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (2)
internal/web/usage_test.go (1)
172-212: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winAdd a regression test for historical-store load failures.
Please add a case where
handleTaskStatshits aStore.Loaderror path and assert the HTTP behavior (status/body). This prevents future regressions where load failures quietly surface as empty stats.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/web/usage_test.go` around lines 172 - 212, Add a test case to verify error handling when the usage Store.Load operation fails. Create a scenario where the Store encounters a load error (by mocking or stubbing the Store to return an error), call handleTaskStats with this failing store, and then assert that the HTTP response returns an appropriate error status code and error details in the response body to prevent silent failures on load errors.internal/usage/event.go (1)
92-98: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winWrap filesystem errors with operation/path context.
These paths currently return raw errors, which makes diagnosing stats-store failures harder at call sites. Wrap them with
%wand include the failing operation/path.Suggested patch
import ( "bufio" "bytes" "encoding/json" + "fmt" "os" "path/filepath" "sync" "time" @@ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err + return fmt.Errorf("usage_store mkdir %s: %w", filepath.Dir(s.path), err) } f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { - return err + return fmt.Errorf("usage_store open %s: %w", s.path, err) } @@ f, err := os.Open(s.path) if err != nil { if os.IsNotExist(err) { return nil, nil } - return nil, err + return nil, fmt.Errorf("usage_store open %s: %w", s.path, err) }As per coding guidelines,
Use fmt.Errorf("tool_name: %w", err) for wrapped errors in non-tool code.Also applies to: 111-117
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/usage/event.go` around lines 92 - 98, The os.MkdirAll and os.OpenFile error returns lack operation and path context, making it difficult to diagnose stats-store failures. Wrap both error returns using fmt.Errorf with the %w verb to include context about the failing operation and the path being accessed (s.path). For the os.MkdirAll error, describe the directory creation failure with the path, and for the os.OpenFile error, describe the file opening failure with the path. Apply the same wrapping pattern to the similar filesystem operations around lines 111-117 to ensure consistent error handling throughout.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/command/web.go`:
- Around line 444-473: The breakdownFn closure reads shared mutable state
(systemPrompt, mcpTools, currentCM, and skillLoader) without synchronization,
creating a data race risk in the concurrent web server environment. Protect
access to these shared variables by acquiring an appropriate synchronization
lock (such as a mutex) before reading them within the breakdownFn function, and
release the lock after gathering all necessary data for the breakdown
calculation. Ensure that the entire set of reads happens atomically while
holding the lock to prevent torn or inconsistent state from being observed
during concurrent project switches or MCP reloads.
- Around line 449-471: The breakdownFn function always estimates context tokens
using systemPrompt and buildAllTools(currentCM), but it should dynamically
select between systemPrompt with buildAllTools(currentCM) for normal mode versus
planPrompt with buildPlanTools() for plan mode. Update the breakdownFn function
to check if plan mode is active and use the appropriate prompt and tools
building function to accurately reflect the actual context being used by the
agent in the current mode. This will ensure b.SystemPromptTokens and
b.SystemToolsTokens are correctly calculated regardless of whether plan mode is
enabled.
In `@internal/config/config.go`:
- Around line 455-458: The fmt.Errorf call in the error handling block after
os.UserHomeDir() is not following the project convention for wrapped errors.
Replace the current free-form error message "failed to get home directory: %w"
with the standard format "tool_name: %w" (where tool_name is the appropriate
identifier for this module), maintaining the wrapped error pattern with %w and
the captured err variable.
In `@internal/model/chatmodel.go`:
- Around line 161-164: The CacheObserved() method in TokenUsage incorrectly uses
CachedTokens count as the indicator for whether cache support was detected, but
this conflates actual cache hits with cache reporting capability. A provider may
report caching metadata but have zero cache hits in a session, which should
still count as cache being observed. Refactor CacheObserved() to check a
separate flag or counter that tracks whether caching information was ever
reported by the provider, independent of the actual cached token count. Also
apply the same fix to any related cache observation logic in the 280-301 line
range, ensuring all cache support detection is based on whether the provider
reports caching data rather than on whether cached tokens are greater than zero.
In `@internal/team/manager.go`:
- Around line 757-775: The usage.Event being recorded in the token delta
tracking is missing proper population of the Model and Project fields. The Model
field relies on state.Model which can be empty on the default-model path, and
Project is not set at all, causing analytics to drop these records from by_model
and by_project breakdowns. Enhance the usage.RecordEvent call by ensuring Model
has a fallback value when state.Model is empty and add the Project field to the
usage.Event struct with the appropriate project information, likely obtained
from the manager's dependencies or team state context.
In `@internal/tools/subagent.go`:
- Around line 351-365: The event recording in the RecordEvent call is being
gated by a check that requires d.TotalTokens to be greater than zero, which
prevents recording valid usage data from providers that omit the total_tokens
field while still reporting other token values like prompt or completion tokens.
Remove or modify the if d.TotalTokens > 0 condition to instead check if any of
the individual token fields (PromptTokens, CompletionTokens, CachedTokens,
ReasoningTokens, CacheWriteTokens, or CallCount) have non-zero values, ensuring
that usage events are recorded whenever there is meaningful token usage data
available regardless of whether TotalTokens is present.
In `@internal/usage/stats.go`:
- Around line 93-103: The CacheSupported flag in the aggregation logic is being
set based on whether Cached tokens are greater than zero, but it should instead
be determined by whether cache fields are actually reported by the provider,
regardless of hit count. Remove the current logic that sets CacheSupported =
agg.Totals.Cached > 0 and implement an explicit tracking mechanism (such as a
boolean field that is OR-aggregated across all events) to record whether cache
fields were observed in the data, then use that aggregated signal to determine
CacheSupported instead of inferring it from the Cached value.
- Around line 170-191: The longestStreak function initializes the best variable
to 1, which causes it to return a non-existent streak of length 1 when all date
parsing fails and the dates slice is empty. Change the initialization of the
best variable from 1 to 0 so that when the dates slice is empty (meaning all day
keys failed to parse), the loop does not execute and the function correctly
returns 0 instead of reporting a false streak.
In `@internal/web/usage.go`:
- Around line 133-134: The store.Load function call is ignoring its error return
value using a blank identifier, which masks I/O and corruption problems. Instead
of discarding the error with _, check the error returned from store.Load("") and
handle it appropriately by either logging the error and returning early or
implementing suitable error recovery logic that prevents the function from
continuing with uninitialized or incorrect data.
In `@web/src/components/ChatInput.vue`:
- Around line 29-36: The ctxRingColor computed property uses a hardcoded hex
color value `#E24B4A` for the warning state when token percentage is >= 90, which
violates the color-token contract. Replace this hardcoded hex value with an
appropriate CSS custom property (design token) from src/styles/tokens.css that
represents a warning or error state color. Update the ctxRingColor computed
property to reference this design token using var() syntax instead of the
literal hex value.
In `@web/src/components/ContextCapacityPopup.vue`:
- Line 144: The box-shadow property in ContextCapacityPopup.vue contains a
hardcoded RGBA color fallback value (rgba(0, 0, 0, 0.16)) which violates the
tokenized theme contract. Replace the hardcoded rgba color value in the
box-shadow fallback with a CSS custom property token defined in
src/styles/tokens.css. If a suitable shadow token does not already exist, add a
new token to the tokens.css file that defines the appropriate shadow value, then
reference that token in the var() function fallback instead of the hardcoded
color.
In `@web/src/components/UsageStatsPanel.vue`:
- Around line 141-145: The hardcoded "tokens" unit string in the cellTitle
function is not localized and will remain in English regardless of locale
changes. Replace the hardcoded "tokens" string with a translation reference
using the i18n pattern (similar to how `t('settings.usageStats.turnsUnit')` is
used for the turns unit). Create a new i18n key like
`settings.usageStats.tokensUnit` and update both the cellTitle function and the
other locations mentioned in lines 151-153 to use this localization key instead
of the hardcoded English string.
In `@web/src/stores/usage.ts`:
- Around line 18-25: In the fetchTaskStats function, the previous taskStats
value remains visible until the new API request completes, causing stale data to
display when switching sessions. To fix this, clear the taskStats value to null
immediately after setting taskLoading.value to true and before making the
api.taskStats call, ensuring no stale data is shown while the new session's data
is being fetched.
---
Nitpick comments:
In `@internal/usage/event.go`:
- Around line 92-98: The os.MkdirAll and os.OpenFile error returns lack
operation and path context, making it difficult to diagnose stats-store
failures. Wrap both error returns using fmt.Errorf with the %w verb to include
context about the failing operation and the path being accessed (s.path). For
the os.MkdirAll error, describe the directory creation failure with the path,
and for the os.OpenFile error, describe the file opening failure with the path.
Apply the same wrapping pattern to the similar filesystem operations around
lines 111-117 to ensure consistent error handling throughout.
In `@internal/web/usage_test.go`:
- Around line 172-212: Add a test case to verify error handling when the usage
Store.Load operation fails. Create a scenario where the Store encounters a load
error (by mocking or stubbing the Store to return an error), call
handleTaskStats with this failing store, and then assert that the HTTP response
returns an appropriate error status code and error details in the response body
to prevent silent failures on load errors.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f5cd36c4-c6e4-453a-a522-bf3652becabb
📒 Files selected for processing (33)
docs/usage-stats.mdinternal/command/web.gointernal/config/config.gointernal/handler/handler.gointernal/handler/web.gointernal/model/chatmodel.gointernal/model/token_ctx.gointernal/model/token_usage_test.gointernal/runner/runner.gointernal/session/session.gointernal/team/manager.gointernal/telemetry/langfuse.gointernal/tools/subagent.gointernal/usage/estimate.gointernal/usage/event.gointernal/usage/stats.gointernal/usage/usage_test.gointernal/web/server.gointernal/web/usage.gointernal/web/usage_test.goweb/src/components/ChatInput.vueweb/src/components/ContextCapacityPopup.vueweb/src/components/SettingsDialog.vueweb/src/components/UsageStatsPanel.vueweb/src/composables/api.tsweb/src/i18n/locales/en.tsweb/src/i18n/locales/ja.tsweb/src/i18n/locales/ko.tsweb/src/i18n/locales/zh-Hans.tsweb/src/i18n/locales/zh-Hant.tsweb/src/stores/chat.tsweb/src/stores/usage.tsweb/src/types/api.ts
| // breakdownFn estimates how the live agent's context window is partitioned | ||
| // across system prompt / built-in tools / MCP tools / skills. It reads the | ||
| // captured assembly variables (systemPrompt, mcpTools, currentCM, skillLoader) | ||
| // by reference, so project switches and MCP reloads are reflected without any | ||
| // cache to invalidate. Built-in tools = all tools minus MCP tools. | ||
| breakdownFn := func() usage.ContextBreakdown { | ||
| var b usage.ContextBreakdown | ||
| skillDesc := skillLoader.Descriptions() | ||
| b.SkillsTokens = usage.Estimate(skillDesc) | ||
| // Skills are injected into the system prompt, so subtract to avoid | ||
| // double-counting them in the system-prompt bucket. | ||
| b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens | ||
| if b.SystemPromptTokens < 0 { | ||
| b.SystemPromptTokens = 0 | ||
| } | ||
| for _, mt := range mcpTools { | ||
| b.MCPToolsTokens += estimateToolTokens(ctx, mt) | ||
| } | ||
| if currentCM != nil { | ||
| total := 0 | ||
| for _, at := range buildAllTools(currentCM) { | ||
| total += estimateToolTokens(ctx, at) | ||
| } | ||
| b.SystemToolsTokens = total - b.MCPToolsTokens | ||
| if b.SystemToolsTokens < 0 { | ||
| b.SystemToolsTokens = 0 | ||
| } | ||
| } | ||
| return b | ||
| } |
There was a problem hiding this comment.
breakdownFn reads shared mutable state without synchronization.
This closure reads systemPrompt, mcpTools, currentCM, and mode-dependent tool composition while other request paths mutate those values (project switch, MCP reload, agent rebuild). In the web server’s concurrent request model, this is a data-race risk and can return torn/unstable breakdowns.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/command/web.go` around lines 444 - 473, The breakdownFn closure
reads shared mutable state (systemPrompt, mcpTools, currentCM, and skillLoader)
without synchronization, creating a data race risk in the concurrent web server
environment. Protect access to these shared variables by acquiring an
appropriate synchronization lock (such as a mutex) before reading them within
the breakdownFn function, and release the lock after gathering all necessary
data for the breakdown calculation. Ensure that the entire set of reads happens
atomically while holding the lock to prevent torn or inconsistent state from
being observed during concurrent project switches or MCP reloads.
| breakdownFn := func() usage.ContextBreakdown { | ||
| var b usage.ContextBreakdown | ||
| skillDesc := skillLoader.Descriptions() | ||
| b.SkillsTokens = usage.Estimate(skillDesc) | ||
| // Skills are injected into the system prompt, so subtract to avoid | ||
| // double-counting them in the system-prompt bucket. | ||
| b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens | ||
| if b.SystemPromptTokens < 0 { | ||
| b.SystemPromptTokens = 0 | ||
| } | ||
| for _, mt := range mcpTools { | ||
| b.MCPToolsTokens += estimateToolTokens(ctx, mt) | ||
| } | ||
| if currentCM != nil { | ||
| total := 0 | ||
| for _, at := range buildAllTools(currentCM) { | ||
| total += estimateToolTokens(ctx, at) | ||
| } | ||
| b.SystemToolsTokens = total - b.MCPToolsTokens | ||
| if b.SystemToolsTokens < 0 { | ||
| b.SystemToolsTokens = 0 | ||
| } | ||
| } |
There was a problem hiding this comment.
Context breakdown currently misreports in plan mode.
breakdownFn always estimates from systemPrompt + buildAllTools(currentCM), but the active agent in plan mode uses planPrompt + buildPlanTools(). This overstates static context usage and can skew messages_tokens/capacity UI while in plan mode.
Suggested fix
breakdownFn := func() usage.ContextBreakdown {
var b usage.ContextBreakdown
+ prompt := systemPrompt
+ var toolsForMode []tool.BaseTool
+ if currentCM != nil {
+ if currentPlanMode {
+ prompt = planPrompt
+ toolsForMode = buildPlanTools()
+ } else {
+ toolsForMode = buildAllTools(currentCM)
+ }
+ }
skillDesc := skillLoader.Descriptions()
- b.SkillsTokens = usage.Estimate(skillDesc)
+ if !currentPlanMode {
+ b.SkillsTokens = usage.Estimate(skillDesc)
+ }
- b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens
+ b.SystemPromptTokens = usage.Estimate(prompt) - b.SkillsTokens
if b.SystemPromptTokens < 0 { b.SystemPromptTokens = 0 }
- for _, mt := range mcpTools {
- b.MCPToolsTokens += estimateToolTokens(ctx, mt)
- }
- if currentCM != nil {
+ if !currentPlanMode {
+ for _, mt := range mcpTools {
+ b.MCPToolsTokens += estimateToolTokens(ctx, mt)
+ }
+ }
+ if len(toolsForMode) > 0 {
total := 0
- for _, at := range buildAllTools(currentCM) {
+ for _, at := range toolsForMode {
total += estimateToolTokens(ctx, at)
}
b.SystemToolsTokens = total - b.MCPToolsTokens
if b.SystemToolsTokens < 0 { b.SystemToolsTokens = 0 }
}
return b
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| breakdownFn := func() usage.ContextBreakdown { | |
| var b usage.ContextBreakdown | |
| skillDesc := skillLoader.Descriptions() | |
| b.SkillsTokens = usage.Estimate(skillDesc) | |
| // Skills are injected into the system prompt, so subtract to avoid | |
| // double-counting them in the system-prompt bucket. | |
| b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens | |
| if b.SystemPromptTokens < 0 { | |
| b.SystemPromptTokens = 0 | |
| } | |
| for _, mt := range mcpTools { | |
| b.MCPToolsTokens += estimateToolTokens(ctx, mt) | |
| } | |
| if currentCM != nil { | |
| total := 0 | |
| for _, at := range buildAllTools(currentCM) { | |
| total += estimateToolTokens(ctx, at) | |
| } | |
| b.SystemToolsTokens = total - b.MCPToolsTokens | |
| if b.SystemToolsTokens < 0 { | |
| b.SystemToolsTokens = 0 | |
| } | |
| } | |
| breakdownFn := func() usage.ContextBreakdown { | |
| var b usage.ContextBreakdown | |
| prompt := systemPrompt | |
| var toolsForMode []tool.BaseTool | |
| if currentCM != nil { | |
| if currentPlanMode { | |
| prompt = planPrompt | |
| toolsForMode = buildPlanTools() | |
| } else { | |
| toolsForMode = buildAllTools(currentCM) | |
| } | |
| } | |
| skillDesc := skillLoader.Descriptions() | |
| if !currentPlanMode { | |
| b.SkillsTokens = usage.Estimate(skillDesc) | |
| } | |
| // Skills are injected into the system prompt, so subtract to avoid | |
| // double-counting them in the system-prompt bucket. | |
| b.SystemPromptTokens = usage.Estimate(prompt) - b.SkillsTokens | |
| if b.SystemPromptTokens < 0 { | |
| b.SystemPromptTokens = 0 | |
| } | |
| if !currentPlanMode { | |
| for _, mt := range mcpTools { | |
| b.MCPToolsTokens += estimateToolTokens(ctx, mt) | |
| } | |
| } | |
| if len(toolsForMode) > 0 { | |
| total := 0 | |
| for _, at := range toolsForMode { | |
| total += estimateToolTokens(ctx, at) | |
| } | |
| b.SystemToolsTokens = total - b.MCPToolsTokens | |
| if b.SystemToolsTokens < 0 { | |
| b.SystemToolsTokens = 0 | |
| } | |
| } | |
| return b | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/command/web.go` around lines 449 - 471, The breakdownFn function
always estimates context tokens using systemPrompt and buildAllTools(currentCM),
but it should dynamically select between systemPrompt with
buildAllTools(currentCM) for normal mode versus planPrompt with buildPlanTools()
for plan mode. Update the breakdownFn function to check if plan mode is active
and use the appropriate prompt and tools building function to accurately reflect
the actual context being used by the agent in the current mode. This will ensure
b.SystemPromptTokens and b.SystemToolsTokens are correctly calculated regardless
of whether plan mode is enabled.
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to get home directory: %w", err) | ||
| } |
There was a problem hiding this comment.
Use the project-standard wrapped error prefix.
Line [457] uses a free-form wrapped error string; use the repo convention fmt.Errorf("tool_name: %w", err) for non-tool code.
Suggested change
- return "", fmt.Errorf("failed to get home directory: %w", err)
+ return "", fmt.Errorf("usage_dir: %w", err)As per coding guidelines, **/*.go: Use fmt.Errorf("tool_name: %w", err) for wrapped errors in non-tool code.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| home, err := os.UserHomeDir() | |
| if err != nil { | |
| return "", fmt.Errorf("failed to get home directory: %w", err) | |
| } | |
| home, err := os.UserHomeDir() | |
| if err != nil { | |
| return "", fmt.Errorf("usage_dir: %w", err) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/config/config.go` around lines 455 - 458, The fmt.Errorf call in the
error handling block after os.UserHomeDir() is not following the project
convention for wrapped errors. Replace the current free-form error message
"failed to get home directory: %w" with the standard format "tool_name: %w"
(where tool_name is the appropriate identifier for this module), maintaining the
wrapped error pattern with %w and the captured err variable.
Source: Coding guidelines
| // CacheObserved reports whether any cache-read tokens have been seen, used to | ||
| // distinguish "cache hit rate is 0%" from "this provider never reports caching". | ||
| func (t *TokenUsage) CacheObserved() bool { | ||
| return atomic.LoadInt64(&t.CachedTokens) > 0 |
There was a problem hiding this comment.
Cache support detection is tied to cache hits instead of cache reporting.
CacheObserved() (Line 163) currently returns true only when cached tokens are > 0. That marks providers as “unsupported” for sessions where caching metadata is reported but all calls happen to have zero cache hits, which violates the “never reports caching data” behavior target.
Suggested fix direction
type AddParams struct {
Prompt int
Completion int
Total int
Cached int
Reasoning int
CacheWrite int
+ CacheReported bool
}
func extractUsage(u openai.Usage) AddParams {
p := AddParams{
Prompt: u.PromptTokens,
Completion: u.CompletionTokens,
Total: u.TotalTokens,
}
if u.PromptTokensDetails != nil {
p.Cached = u.PromptTokensDetails.CachedTokens
+ p.CacheReported = true
}
...
}
type TokenUsage struct {
...
+ cacheObserved int64
}
func (t *TokenUsage) Add(p AddParams) {
...
+ if p.CacheReported {
+ atomic.StoreInt64(&t.cacheObserved, 1)
+ }
}
func (t *TokenUsage) CacheObserved() bool {
- return atomic.LoadInt64(&t.CachedTokens) > 0
+ return atomic.LoadInt64(&t.cacheObserved) == 1
}
func (t *TokenUsage) Reset() {
...
+ atomic.StoreInt64(&t.cacheObserved, 0)
}Also applies to: 280-301
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/model/chatmodel.go` around lines 161 - 164, The CacheObserved()
method in TokenUsage incorrectly uses CachedTokens count as the indicator for
whether cache support was detected, but this conflates actual cache hits with
cache reporting capability. A provider may report caching metadata but have zero
cache hits in a session, which should still count as cache being observed.
Refactor CacheObserved() to check a separate flag or counter that tracks whether
caching information was ever reported by the provider, independent of the actual
cached token count. Also apply the same fix to any related cache observation
logic in the 280-301 line range, ensuring all cache support detection is based
on whether the provider reports caching data rather than on whether cached
tokens are greater than zero.
| // Roll this teammate's per-turn token delta into the global usage log under | ||
| // the leader's session so team work counts toward global stats. | ||
| if state.TokenUsage != nil && m.deps.LeaderSessionUUID != "" { | ||
| full := state.TokenUsage.GetFull() | ||
| delta := full.Minus(state.LastUsage) | ||
| state.LastUsage = full | ||
| if delta.TotalTokens > 0 { | ||
| usage.RecordEvent(usage.Event{ | ||
| Session: m.deps.LeaderSessionUUID, | ||
| Model: state.Model, | ||
| Prompt: delta.PromptTokens, | ||
| Completion: delta.CompletionTokens, | ||
| Cached: delta.CachedTokens, | ||
| Reasoning: delta.ReasoningTokens, | ||
| CacheWrite: delta.CacheWriteTokens, | ||
| Total: delta.TotalTokens, | ||
| Calls: delta.CallCount, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Populate Model and Project when recording teammate deltas.
Model is sourced from state.Model, which is empty on the default-model path, and Project is not set. Those turns still increase totals but get dropped from by_model / by_project analytics.
Suggested patch
if state.TokenUsage != nil && m.deps.LeaderSessionUUID != "" {
full := state.TokenUsage.GetFull()
delta := full.Minus(state.LastUsage)
state.LastUsage = full
if delta.TotalTokens > 0 {
+ modelName := state.Model
+ if modelName == "" && state.Recorder != nil {
+ modelName = state.Recorder.Model()
+ }
usage.RecordEvent(usage.Event{
Session: m.deps.LeaderSessionUUID,
- Model: state.Model,
+ Project: cwd,
+ Model: modelName,
Prompt: delta.PromptTokens,
Completion: delta.CompletionTokens,
Cached: delta.CachedTokens,
Reasoning: delta.ReasoningTokens,
CacheWrite: delta.CacheWriteTokens,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/team/manager.go` around lines 757 - 775, The usage.Event being
recorded in the token delta tracking is missing proper population of the Model
and Project fields. The Model field relies on state.Model which can be empty on
the default-model path, and Project is not set at all, causing analytics to drop
these records from by_model and by_project breakdowns. Enhance the
usage.RecordEvent call by ensuring Model has a fallback value when state.Model
is empty and add the Project field to the usage.Event struct with the
appropriate project information, likely obtained from the manager's dependencies
or team state context.
| events, _ := store.Load("") | ||
| sel := make([]usage.Event, 0) |
There was a problem hiding this comment.
Do not ignore historical usage load errors.
Ignoring store.Load failures silently turns I/O/corruption problems into zeroed task stats, which is misleading for users and hard to debug.
Suggested patch
- events, _ := store.Load("")
+ events, err := store.Load("")
+ if err != nil {
+ config.Logger().Printf("[usage] task stats load failed: %v", err)
+ http.Error(w, "failed to load task usage stats", http.StatusInternalServerError)
+ return
+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/web/usage.go` around lines 133 - 134, The store.Load function call
is ignoring its error return value using a blank identifier, which masks I/O and
corruption problems. Instead of discarding the error with _, check the error
returned from store.Load("") and handle it appropriately by either logging the
error and returning early or implementing suitable error recovery logic that
prevents the function from continuing with uninitialized or incorrect data.
| // Context-fill ring on the composer: the orange arc fills with the % of the | ||
| // context window in use, turning red as it approaches the limit. | ||
| const ctxRingCirc = 2 * Math.PI * 6.4 | ||
| const ctxRingOffset = computed(() => { | ||
| const p = Math.min(100, Math.max(0, store.tokenPercentage)) | ||
| return ctxRingCirc * (1 - p / 100) | ||
| }) | ||
| const ctxRingColor = computed(() => (store.tokenPercentage >= 90 ? '#E24B4A' : 'var(--color-primary)')) |
There was a problem hiding this comment.
Replace hardcoded ring warning color with a design token.
The #E24B4A literal bypasses theme tokens and violates the color-token contract.
Suggested patch
-const ctxRingColor = computed(() => (store.tokenPercentage >= 90 ? '`#E24B4A`' : 'var(--color-primary)'))
+const ctxRingColor = computed(() =>
+ store.tokenPercentage >= 90 ? 'var(--color-destructive)' : 'var(--color-primary)')As per coding guidelines, "Every color must come from a CSS custom property defined in src/styles/tokens.css. Never hardcode hex/rgb/#fff/white in .vue or .css."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Context-fill ring on the composer: the orange arc fills with the % of the | |
| // context window in use, turning red as it approaches the limit. | |
| const ctxRingCirc = 2 * Math.PI * 6.4 | |
| const ctxRingOffset = computed(() => { | |
| const p = Math.min(100, Math.max(0, store.tokenPercentage)) | |
| return ctxRingCirc * (1 - p / 100) | |
| }) | |
| const ctxRingColor = computed(() => (store.tokenPercentage >= 90 ? '#E24B4A' : 'var(--color-primary)')) | |
| // Context-fill ring on the composer: the orange arc fills with the % of the | |
| // context window in use, turning red as it approaches the limit. | |
| const ctxRingCirc = 2 * Math.PI * 6.4 | |
| const ctxRingOffset = computed(() => { | |
| const p = Math.min(100, Math.max(0, store.tokenPercentage)) | |
| return ctxRingCirc * (1 - p / 100) | |
| }) | |
| const ctxRingColor = computed(() => | |
| store.tokenPercentage >= 90 ? 'var(--color-destructive)' : 'var(--color-primary)') |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ChatInput.vue` around lines 29 - 36, The ctxRingColor
computed property uses a hardcoded hex color value `#E24B4A` for the warning state
when token percentage is >= 90, which violates the color-token contract. Replace
this hardcoded hex value with an appropriate CSS custom property (design token)
from src/styles/tokens.css that represents a warning or error state color.
Update the ctxRingColor computed property to reference this design token using
var() syntax instead of the literal hex value.
Source: Coding guidelines
| border-radius: var(--radius-md); | ||
| background: var(--color-background); | ||
| border: 1px solid var(--color-border); | ||
| box-shadow: var(--elevation-popover, 0 8px 24px rgba(0, 0, 0, 0.16)); |
There was a problem hiding this comment.
Use token-based shadow fallback instead of hardcoded RGBA.
The current fallback hardcodes color values and breaks the tokenized theme contract.
Suggested patch
- box-shadow: var(--elevation-popover, 0 8px 24px rgba(0, 0, 0, 0.16));
+ box-shadow: var(--elevation-popover, var(--shadow-lg));As per coding guidelines, "Every color must come from a CSS custom property defined in src/styles/tokens.css. Never hardcode hex/rgb/#fff/white in .vue or .css."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ContextCapacityPopup.vue` at line 144, The box-shadow
property in ContextCapacityPopup.vue contains a hardcoded RGBA color fallback
value (rgba(0, 0, 0, 0.16)) which violates the tokenized theme contract. Replace
the hardcoded rgba color value in the box-shadow fallback with a CSS custom
property token defined in src/styles/tokens.css. If a suitable shadow token does
not already exist, add a new token to the tokens.css file that defines the
appropriate shadow value, then reference that token in the var() function
fallback instead of the hardcoded color.
Source: Coding guidelines
| function cellTitle(c: HeatCell): string { | ||
| if (c.future) return '' | ||
| if (c.tokens <= 0) return `${fmtDayLabel(c.date)} · ${t('settings.usageStats.noActivity')}` | ||
| return `${fmtDayLabel(c.date)} · ${fmtCompact(c.tokens)} tokens · ${c.turns} ${t('settings.usageStats.turnsUnit')}` | ||
| } |
There was a problem hiding this comment.
Localize the hardcoded "tokens" unit in tooltip labels.
These strings stay English even when the locale changes. Please move the unit to i18n keys (parallel to turnsUnit) and reference it here.
Suggested patch
function cellTitle(c: HeatCell): string {
if (c.future) return ''
if (c.tokens <= 0) return `${fmtDayLabel(c.date)} · ${t('settings.usageStats.noActivity')}`
- return `${fmtDayLabel(c.date)} · ${fmtCompact(c.tokens)} tokens · ${c.turns} ${t('settings.usageStats.turnsUnit')}`
+ return `${fmtDayLabel(c.date)} · ${fmtCompact(c.tokens)} ${t('settings.usageStats.tokensUnit')} · ${c.turns} ${t('settings.usageStats.turnsUnit')}`
}
...
function barTitle(d: UsageDayBucket): string {
- return `${fmtDayLabel(d.date)} · ${fmtCompact(d.tokens)} tokens · ${d.turns} ${t('settings.usageStats.turnsUnit')}`
+ return `${fmtDayLabel(d.date)} · ${fmtCompact(d.tokens)} ${t('settings.usageStats.tokensUnit')} · ${d.turns} ${t('settings.usageStats.turnsUnit')}`
}Also applies to: 151-153
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/UsageStatsPanel.vue` around lines 141 - 145, The hardcoded
"tokens" unit string in the cellTitle function is not localized and will remain
in English regardless of locale changes. Replace the hardcoded "tokens" string
with a translation reference using the i18n pattern (similar to how
`t('settings.usageStats.turnsUnit')` is used for the turns unit). Create a new
i18n key like `settings.usageStats.tokensUnit` and update both the cellTitle
function and the other locations mentioned in lines 151-153 to use this
localization key instead of the hardcoded English string.
| async function fetchTaskStats(uuid: string) { | ||
| if (!uuid) return | ||
| taskLoading.value = true | ||
| try { | ||
| taskStats.value = await api.taskStats(uuid) | ||
| } catch { | ||
| taskStats.value = null | ||
| } finally { |
There was a problem hiding this comment.
Clear previous taskStats before fetching a new session.
taskStats is kept from the last session until the new request returns, so the context popup can momentarily show stale data after switching sessions.
Suggested patch
async function fetchTaskStats(uuid: string) {
- if (!uuid) return
+ taskStats.value = null
+ if (!uuid) return
taskLoading.value = true
try {
taskStats.value = await api.taskStats(uuid)
} catch {
taskStats.value = null
} finally {
taskLoading.value = false
}
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/stores/usage.ts` around lines 18 - 25, In the fetchTaskStats
function, the previous taskStats value remains visible until the new API request
completes, causing stale data to display when switching sessions. To fix this,
clear the taskStats value to null immediately after setting taskLoading.value to
true and before making the api.taskStats call, ensuring no stale data is shown
while the new session's data is being fetched.
What
Adds usage statistics to jcode across TUI / Web / ACP, plus the underlying token-tracking refactor needed to make it accurate.
Global stats (Settings → 使用统计)
GET /api/usage/stats?days=N→ tokens used, sessions, turns, active days, current/longest streak, most-used model, an activity heatmap (365d), a daily token trend, and per-model / per-project breakdowns. Rendered with hand-rolled SVG (no chart lib), orange theme preserved.Per-task context capacity (composer ring + popup)
A context-fill ring on the composer shows the % of the context window in use (turns red ≥90%). Clicking it opens a popup breaking the window into Messages / System tools / MCP tools / Skills / System prompt + Free space, each as a share of the full window, plus the KV cache hit rate and the conversation's cumulative tokens.
GET /api/tasks/{id}/stats.Token tracking refactor (
internal/model)TokenUsagegainsReasoningTokens/CacheWriteTokens/CallCount;Add()takes anAddParamsstruct.total_tokens(e.g. GLM) and records once per streamed call.—when caching is never reported).Persistence (
internal/usage)Append-only event log at
~/.jcode/usage/events.jsonl(atomicO_APPEND, multi-process safe). All metrics are derived at read time byAggregate. Subagent and teammate tokens roll up under the leader session.Notes / limitations
cache_creationisn't available over the shared go-openai transport, soCacheWriteTokensstays 0 (future-proofed).ModelCostexists; a spend view is a follow-up).docs/usage-stats.md.Testing
go build ./...,go vet ./...,gofmt— clean.internal/model), aggregation/streaks (internal/usage), endpoints via in-process httptest (internal/web).vue-tsc+vite buildpass.🤖 Generated with Claude Code
Summary by CodeRabbit
/api/usage/statsand/api/tasks/{id}/statsendpoints for programmatic access to usage data